File name
Commit message
Commit date
File name
Commit message
Commit date
09-16
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState } from 'react';
import { FoxAlert } from '@fox/core/components/fox-alert';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
import { FoxCard } from '@fox/core/components/fox-card';
import { FoxDescriptionList } from '@fox/core/components/fox-description-list';
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
import { FoxInput } from '@fox/core/components/fox-input';
import { FoxPageHeader } from '@fox/core/components/fox-page-header';
import { FoxSelect } from '@fox/core/components/fox-select';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
import { useFeedback } from '@/app/_hooks/use-feedback';
import type { CommonCode } from '@/lib/domain/common-code';
import {
MERCHANT_DETAIL_VISIBILITY_OPTIONS,
MERCHANT_VOUCHER_USE_OPTIONS,
formatMerchantDetailStatusLabel,
formatOptionalValue,
type MerchantDetail,
type MerchantDetailSummary,
} from '@/lib/domain/merchant-detail';
import {
CLOSED_DAYS_MAX_LENGTH,
DESCRIPTION_MAX_LENGTH,
INITIAL_MERCHANT_DETAIL_FORM_STATE,
SORT_ORDER_MAX,
} from '@/lib/domain/merchant-detail-form';
import { MERCHANT_DETAILS_PATH, merchantDetailPath } from '@/lib/domain/merchant-detail-query';
import type { MerchantProduct } from '@/lib/domain/merchant-product';
import { formatPhoneNumber } from '@/lib/domain/phone-number';
import {
deleteMerchantDetailAction,
saveMerchantDetailAction,
updateMerchantDetailVisibilityAction,
} from '../../_actions';
import { MerchantChoiceChips } from '../../_components/merchant-choice-chips';
import { MerchantImageField } from '../../_components/merchant-image-field';
import { MerchantProductTable } from '../../_components/merchant-product-table';
import styles from './merchant-detail-form.module.scss';
interface MerchantDetailFormProps {
/** 이 화면의 가맹점 — 업체명·연락처·주소의 출처. 상세 유무와 무관하게 늘 정해져 있다. */
merchant: MerchantDetailSummary;
/** 상세가 이미 있으면 그 값. 없으면 이 화면에서 처음 만든다. */
detail?: MerchantDetail;
products?: MerchantProduct[];
categories: CommonCode[];
}
/**
* 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). 목록의 행에서
* 들어오며 `detail`이 있으면 고치고, 없으면 처음 만든다. 첫 저장 뒤에도 화면 구성은 그대로라
* 바로 메뉴를 붙일 수 있고, 저장은 그 자리에 머문다.
*
* 업체명·연락처·우편번호·주소는 가맹점 원본이라 늘 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은
* 가맹점 관리 화면의 것이라 여기서는 값을 바꾸지 않는다.
*/
export function MerchantDetailForm({
merchant,
detail,
products = [],
categories,
}: MerchantDetailFormProps) {
const router = useRouter();
const { showAlert, hideAlert, showToast } = useFeedback();
const [isBusy, startAction] = useBlockingAction();
const [state, formAction, isPending] = useActionState(
saveMerchantDetailAction,
INITIAL_MERCHANT_DETAIL_FORM_STATE
);
const isEdit = detail !== undefined;
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
const frcsNo = merchant.id;
const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
const [visibility, setVisibility] = useState<string[]>([
detail?.isVisible === false ? 'N' : 'Y',
]);
const [voucherUse, setVoucherUse] = useState<string[]>(['Y']);
useEffect(() => {
if (state.status !== 'success') return;
if (state.created) {
showToast({ variant: 'success', message: '등록했습니다. 이어서 메뉴를 추가할 수 있습니다.' });
router.replace(merchantDetailPath(state.frcsNo));
} else {
showToast({ variant: 'success', message: '저장했습니다.' });
router.refresh();
}
}, [state, router, showToast]);
function toggleVisibility() {
if (!detail) return;
const next = detail.isVisible === false;
startAction(async () => {
const result = await updateMerchantDetailVisibilityAction(detail.id, next);
showToast(
result.status === 'error'
? { variant: 'danger', message: result.message }
: { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' }
);
});
}
function runDelete() {
if (!detail) return;
hideAlert();
startAction(async () => {
const result = await deleteMerchantDetailAction(detail.id);
if (result.status === 'error') {
showToast({ variant: 'danger', message: result.message });
return;
}
showToast({ variant: 'success', message: '삭제했습니다.' });
router.push(MERCHANT_DETAILS_PATH);
});
}
function confirmDelete() {
if (!detail) return;
showAlert({
variant: 'danger',
title: '가맹몰 상세를 삭제하시겠습니까?',
message: `"${detail.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
actions: (
<FoxButtonGroup size="md">
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
<FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
</FoxButtonGroup>
),
});
}
return (
<>
<FoxPageHeader
title="가맹몰 상세 정보"
description="지역상생몰에 노출할 업체의 상세를 등록·수정한다. 저장 즉시 앱에 반영된다"
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '관리자관리' },
{ label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH },
{ label: '상세 정보' },
]}
/>
{/* TODO(백엔드): 단건 SELECT에 상세 컬럼이 빠져 저장한 값이 비어 보이고, 그대로 저장하면 빈 값으로
덮어써진다. 컬럼이 추가되면 이 안내를 지운다. */}
{detail && (
<FoxAlert
type="warning"
title="저장한 값 일부가 비어 보입니다"
message="한줄 소개·카테고리·영업시간·휴무일·노출 순서·추가 이미지는 저장되지만 백엔드 조회가 아직 돌려주지 않아 여기서는 비어 보입니다. 이 상태로 [저장]하면 빈 값으로 덮어써지니 값을 다시 입력한 뒤 저장해 주세요."
/>
)}
<form
id="merchant-detail-form"
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.form}
>
<input type="hidden" name="frcsNo" value={frcsNo} />
<input type="hidden" name="mode" value={isEdit ? 'edit' : 'create'} />
<FoxCard size="lg" title="기본 정보" className={styles.section}>
<div className={styles.grid}>
<div className={styles.fieldWide}>
<FoxInput size="md" label="업체명" requirement="required" value={merchant.name} readOnly state="view" />
</div>
<div className={styles.field}>
<FoxSelect
size="md"
label="카테고리"
requirement="required"
placeholder="선택"
options={categories.map((code) => ({ value: code.code, label: code.label }))}
value={categoryCode}
onValueChange={setCategoryCode}
error={Boolean(errors.categoryCode)}
/>
<input type="hidden" name="categoryCode" value={categoryCode} />
{errors.categoryCode && <FoxHelperText type="danger" message={errors.categoryCode} />}
</div>
<div className={styles.field}>
<FoxInput
size="md"
label="업체 연락처"
requirement="required"
value={formatOptionalValue(formatPhoneNumber(merchant.phoneNumber))}
readOnly
state="view"
/>
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldNarrow}>
<FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail?.postalCode)} readOnly state="view" />
</div>
<div className={styles.fieldWide}>
<FoxInput size="md" label="주소" value={formatOptionalValue(merchant.address)} readOnly state="view" />
</div>
<div className={styles.field}>
<FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant.addressDetail)} readOnly state="view" />
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldWide}>
<FoxInput
size="md"
name="description"
label="한줄 소개"
placeholder="앱 목록·상세에 노출되는 한 줄 설명"
defaultValue={detail?.description ?? ''}
maxLength={DESCRIPTION_MAX_LENGTH}
invalid={Boolean(errors.description)}
message={errors.description}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="openTime"
type="time"
label="영업 시작"
defaultValue={detail?.openTime ?? ''}
invalid={Boolean(errors.openTime)}
message={errors.openTime}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="closeTime"
type="time"
label="영업 종료"
defaultValue={detail?.closeTime ?? ''}
invalid={Boolean(errors.closeTime)}
message={errors.closeTime}
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="closedDays"
label="휴무일"
placeholder="예) 매주 월요일"
defaultValue={detail?.closedDays ?? ''}
maxLength={CLOSED_DAYS_MAX_LENGTH}
invalid={Boolean(errors.closedDays)}
message={errors.closedDays}
/>
</div>
</div>
<div className={styles.grid}>
<MerchantChoiceChips
label="노출 상태"
name="isVisible"
required
options={MERCHANT_DETAIL_VISIBILITY_OPTIONS}
values={visibility}
onChange={setVisibility}
/>
<MerchantChoiceChips
label="바우처 사용"
name="isVoucherUsable"
required
options={MERCHANT_VOUCHER_USE_OPTIONS}
values={voucherUse}
onChange={setVoucherUse}
/>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="sortOrder"
type="number"
inputMode="numeric"
min={0}
max={SORT_ORDER_MAX}
label="노출 순서"
placeholder="숫자 (낮을수록 상단)"
defaultValue={detail?.sortOrder == null ? '' : String(detail.sortOrder)}
invalid={Boolean(errors.sortOrder)}
message={errors.sortOrder}
/>
</div>
</div>
</FoxCard>
<FoxCard
size="lg"
title="이미지 등록"
description="대표 1장 필수 · 추가 최대 11장 (앱 슬라이더 최대 12장)"
className={styles.section}
>
{/* 저장으로 파일 id가 바뀌면 다시 마운트해 칸 상태를 서버 값으로 되돌린다. */}
<MerchantImageField
key={`${detail?.thumbnailFileId ?? ''}/${detail?.imageFileId ?? ''}`}
defaultThumbnailUrl={detail?.thumbnailUrl ?? null}
retainedThumbnailId={detail?.thumbnailFileId ?? null}
defaultImageUrls={detail?.imageUrls ?? []}
retainedImageFileId={detail?.imageFileId ?? null}
thumbnailError={errors.thumbnail}
imagesError={errors.images}
/>
</FoxCard>
<FoxCard
size="lg"
title="메뉴 정보"
description="앱 상세 화면의 메뉴 목록에 노출된다"
className={styles.section}
>
<MerchantProductTable frcsNo={frcsNo} products={products} canAdd={isEdit} />
</FoxCard>
{detail && (
<FoxCard size="lg" title="등록 정보" className={styles.section}>
<FoxDescriptionList
layout="inline"
items={[
{ key: 'registeredAt', term: '등록일시', description: formatOptionalValue(detail.registeredAt) },
{ key: 'registeredBy', term: '등록자', description: formatOptionalValue(detail.registeredBy) },
{
key: 'modified',
term: '최종 수정',
description: [detail.modifiedAt, detail.modifiedBy]
.filter((value): value is string => Boolean(value))
.join(' / ') || '-',
},
{ key: 'status', term: '현재 상태', description: formatMerchantDetailStatusLabel(detail) },
]}
/>
</FoxCard>
)}
{state.status === 'error' && state.message && (
<FoxHelperText type="danger" message={state.message} />
)}
<FoxButtonPanel>
<FoxButtonGroup size="lg">
{detail && (
<>
<FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} />
<FoxButton
type="default"
size="lg"
label={detail.isVisible === false ? '노출' : '비활성'}
disabled={isBusy}
onAction={toggleVisibility}
/>
</>
)}
<FoxButton
type="default"
size="lg"
label="취소"
onAction={() => router.push(MERCHANT_DETAILS_PATH)}
/>
<FoxButton
type="primary"
size="lg"
htmlType="submit"
label={isEdit ? '저장' : '등록'}
loading={isPending}
/>
</FoxButtonGroup>
</FoxButtonPanel>
</form>
</>
);
}